Skip to content

feat(pdf): export PDFs to DocLang .dclx archives - #227

Open
ludovicc wants to merge 12 commits into
firecrawl:mainfrom
ludovicc:feat/pdf-doclang-export
Open

ludovicc wants to merge 12 commits into
firecrawl:mainfrom
ludovicc:feat/pdf-doclang-export

Conversation

@ludovicc

@ludovicc ludovicc commented Nov 13, 2026 •

Copy link
Copy Markdown

Add export to the DocLang 0.9 standard format for documents. It will help to have more exchange between Firecrawl and the Docling communities. Also, we get a nice viewer for parsed documents for free (https://doclang.ai/viewer/).

This PR is still initial work and limited to PDF documents. My agent had a blast building it, I did some reasonable review of the code and tests.

Summary

  • to_doclang / to_doclang_bytes: PDFs export to .dclx (OPC ZIP) archives containing document.xml, extracted image/page rasters under assets/ and pages/, and the original PDF under /source.
  • Scanned pages work without OCR: image-only pages become <picture> elements with location boxes — NeedsOcr is no longer the outcome for this path.
  • Text PDFs keep structure: positioned runs, inline styles, headings, links, AcroForm fields (<field_item>), drawn images, and page geometry are preserved where practically possible.
  • Lists: bullet/ordered markers + indentation → <list>/<ldiv>/<marker> rather than flattened text.
  • Tables: tagged /Table structure-tree elements are matched by MCID, and a ruled-table engine clusters drawn axis-aligned rules into grids (spans → lcel/ucel/xcel); both emit OTSL-token <table> blocks. DoclangHints::pages_with_tables scopes the geometric detector.
  • Renderer lives under src/render/doclang/ — DocLang is an output format, not an input format.
  • Robustness: malformed operands never corrupt the CTM, paths paint under the CTM active at the stroke, clustered boundaries can't chain-merge, decoded pixels and archive/ZIP sizes are capped (ResourceLimit), page content and Form XObjects decode once per page, and the document parses at most twice (upstream parse-dedup filed as Expose &lopdf::Document variants of *_mem APIs to avoid double parsing pdf-inspector#662).

Test plan

  • cargo test — 315 unit + integration tests (lists, tagged/ruled tables, spans, headers, q/Q CTM, malformed operands, image caps, packaging limits, .dclx round-trip)
  • cargo clippy -- -D warnings clean
  • cargo fmt --check clean
  • DocLang XML validated against the DocLang XSD in the integration tests
  • Reviewer: convert a scanned PDF and a text+table PDF via examples/convert.rs

Generated with Devin


Summary by cubic

Adds DocLang .dclx archive export for PDFs through new to_doclang / to_doclang_bytes APIs, now also exposed from Python. Scanned pages no longer end in NeedsOcr — image-only pages export as <picture> elements with extracted rasters, so this path works without OCR.

  • Text PDFs preserve positioned runs, inline styles, headings, links, AcroForm fields, drawn images, metadata titles, and page geometry.
  • Lists and tables are emitted as DocLang <list>/<table> blocks via tagged /Table structure-tree elements and a ruled-line grid engine.
  • The original PDF goes under /source; rasters pass through as JPEG/JP2 or wrap to PNG/TIFF under assets/ and pages/.
  • DoclangHints toggles list/table detection and scopes the geometric table detector to specific pages; Python to_doclang/to_doclang_bytes accept the same lists, tables, and pages_with_tables options.
  • Export is cross-checked against Docling's own groundtruth exports, covering lists, tagged/ruled tables, RTL text, forms, and scanned pages; results round-trip through Docling's DocLang deserializer.

Robustness

  • All PDF streams (page content, Form XObjects, images, palettes, masks) decode through a bounded Flate/LZW/ASCII85 decoder with strictly validated TIFF/PNG predictors, so decompression and pixel buffers can't exhaust memory.
  • Malformed operands, oversized ruled grids, and unsupported color spaces degrade without corrupting the CTM or table layout; rejected fragments return their items to normal flow.
  • 8-bit indexed images export correctly instead of panicking on a mask overflow, and non-BOM metadata titles decode as PDFDocEncoding.
  • Documents parse at most twice per export, and page content and Form XObjects decode once per page.

Written for commit e741f06. Summary will update on new commits.

View guided diff

ludovicc and others added 2 commits November 13, 2026 09:24
The Markdown path refuses image-only pages with NeedsOcr because text is
the whole output. DocLang's picture+location model can represent them
faithfully, so add a PDF-specific pipeline that exports scanned pages
instead of erroring: positioned runs become text/heading/footnote
elements with formatting, links, fields, and normalized locations, and
image XObjects become picture elements backed by extracted raster parts
(JPEG/JP2 passthrough, decoded samples to PNG, CCITT wrapped in TIFF),
all packed into a spec-0.9 OPC archive with the source PDF alongside.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Move the DocLang renderer under src/render/doclang — it is an output
format, not an input format. Detected lists emit as list/ldiv; tables
come from the structure tree's Table elements (tagged) and from
drawn-rule grid analysis (ruled), emitted as OTSL-token table blocks.
DoclangHints gains pages_with_tables to scope ruled-table detection.

Review fixes: only cell-assigned items are consumed, tagged cells on
other pages keep rectangular placeholders without bogus locations,
malformed operands no longer corrupt the CTM, paths paint under the CTM
active at the stroke, boundary clustering cannot chain-merge, decoded
pixels are capped before allocation, the archive budget covers ZIP
overhead, page content and Form XObjects decode once per page, and the
document parses at most twice.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 14 files

Reply to a comment to ask cubic a question or push back. It learns from your replies.
Click Fix with cubic on a comment, or reply @cubic-dev-ai fix this, and cubic writes the fix.

View guided diff | Re-trigger cubic

Comment thread src/render/doclang/mod.rs Resolved
Comment thread src/render/doclang/images.rs Resolved
Comment thread src/render/doclang/tables.rs Resolved
Comment thread src/render/doclang/tables.rs Resolved
Comment thread src/render/doclang/tables.rs Resolved
Comment thread src/render/doclang/images.rs Resolved
Comment thread src/render/doclang/layout.rs Resolved
Comment thread src/render/doclang/mod.rs Resolved
Comment thread examples/convert.rs Resolved
Comment thread tests/doclang.rs Resolved
Decode PDF streams (page content, Form XObjects, images, palettes,
masks) through a bounded decoder — Flate/LZW/ASCII85 with lopdf's PNG
predictors — instead of unbounded decompressed_content. Ruled tables cap
rules at append and reject oversized grids before the quadratic cell
matrix; rejected tagged fragments return their items to normal flow.
Close-and-stroke ops (s/b/b*) now paint their implied closing segment.

Images: bound the decode by declared dimensions before decompressing,
apply /Decode remaps to samples, indices, and masks, map ICCBased N=4 to
CMYK, reject Separation/DeviceN/Pattern, and fall back to XObject
/Height when CCITT /Rows is absent.

Emission: esc drops XML-1.0-invalid control chars, the field separator
no longer sits between elements, furniture merges only with furniture,
fused (iv)Text markers parse, pictures sharing an XObject name keep
distinct srcs, explicit /Rotate 0 overrides a rotated ancestor, archive
entry limits count OPC plumbing + source, and non-BOM titles decode as
PDFDocEncoding.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 12 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved P0–P2 issue already reported by Cubic.
Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Re-trigger cubic

Comment thread src/render/doclang/streams.rs Resolved
Comment thread src/render/doclang/images.rs Resolved
Comment thread src/render/doclang/streams.rs Resolved
Comment thread src/render/doclang/layout.rs Resolved
Comment thread src/render/doclang/streams.rs Resolved
Comment thread src/render/doclang/streams.rs Resolved
Comment thread src/render/doclang/streams.rs Resolved
Comment thread src/render/doclang/streams.rs Resolved
ludovicc and others added 3 commits November 13, 2026 22:54
Absent /DecodeParms now means predictor 1 instead of decode failure,
/DecodeParms arrays are honored per filter (indirect entries resolved,
nulls default), and an empty /Filter array returns the raw content.
ASCII85 uses checked_add so a five-char overflow group fails cleanly
instead of wrapping.

Predictor handling no longer borrows lopdf's lenient path: TIFF
predictor 2 reconstructs 8/16-bit horizontal differencing (other
depths are rejected rather than corrupted), and PNG predictors use
ceil(Colors*BPC/8) bytes-per-pixel with fully checked row-size
arithmetic, so packed sub-byte components stay aligned and malformed
dimensions cannot overflow before the stream bound applies.

pixel_bound multiplies Width*Height with checked arithmetic, and list
validation keeps every ordinal candidate for ambiguous markers like
(i) so alpha runs (i)(j)(k) and roman runs both validate.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
(1u8 << bpc) panics when bpc is 8 — a valid index size for Indexed
color spaces — and silently produces a zero mask in release builds.
Derive the mask by shifting 0xFF right instead.

Found while running the Docling PDF groundtruth through the DocLang
export; four of the eighteen documents hit the panic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Export Docling's serialized PDF DoclingDocuments to DocLang and use
them, with their PDF sources, as the source of truth for the DocLang
export: every document must produce well-formed .dclx whose text
covers at least 95% of the reference's characters.

The harness reads PDFs from a sibling Docling checkout
(DOCLING_PDF_TESTDATA or ../docling/tests/data/pdf) and skips cleanly
when absent. Reference exports are vendored under
tests/fixtures/doclang and regenerated by
scripts/export_docling_doclang.py. All outputs also round-trip through
Docling's own DocLang deserializer.

Known divergences are documented in the test: newspaper-00 fails in
pdf-inspector, right_to_left_* keeps visual order where Docling emits
logical order, and Docling's layout model detects structures beyond
the tagged/ruled-table and font heuristics.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 21 files (changes from recent commits).

Confidence score: 4/5

  • scripts/export_docling_doclang.py can report success with an empty groundtruth directory, leaving fixtures stale or missing. Reject empty input before regenerating.
  • scripts/export_docling_doclang.py relies on the host locale when reading Docling JSON, so regeneration can fail on non-ASCII text. Read the JSON as UTF-8 explicitly.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/export_docling_doclang.py">

<violation number="1" location="scripts/export_docling_doclang.py:60">
P2: Reject an empty groundtruth directory instead of reporting successful regeneration; a wrong or incomplete checkout can currently leave stale or missing fixtures while returning success.</violation>

<violation number="2" location="scripts/export_docling_doclang.py:65">
P2: Read the Docling JSON as UTF-8 explicitly; relying on the host locale can fail regeneration as soon as a document contains non-ASCII text.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread scripts/export_docling_doclang.py Resolved
Comment thread scripts/export_docling_doclang.py Resolved
Comment thread scripts/export_docling_doclang.py Resolved
ludovicc and others added 5 commits November 14, 2026 02:19
A /Title without a BOM is PDFDocEncoding, not Latin-1: bytes in the 0x80-0x9F range are printable punctuation (bullets, dashes, quotes) rather than C1 controls. Use lopdf's text-string decoder, which also covers the UTF-8 BOM the manual path missed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
name_for_index now returns Option<Result<Cow>> and ZipFile::name returns Result; flatten both layers in the test helpers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
tests/fixtures is the corpus sweep input set; reference exports do not belong there and broke the snapshot harness. Move them to tests/groundtruth and harden the regeneration script: reject an empty groundtruth dir, read JSON as UTF-8, and prune stale .doclang outputs only after a fully successful run.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread python/src/lib.rs Resolved
Comment thread .gitignore Resolved
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant